3W - 타임아웃, 재시도를 활용한 네트워크 복원력
개요
이번 주차 마지막 문서도 네트워크 복원력을 높이기 위한 세팅을 알아본다.
이번에는 Virtual Service 단에서 할 수 있는 설정을 알아보자.
사전 지식
타임아웃 전략
네트워크의 신뢰성을 확보하기 위한 방법 중 하나는 타임아웃을 설정하는 것이다.
요청을 보냈는데, 응답이 돌아오지 않는다고 해서 무한정 기다리는 건 어마무시한 비효율이며 그 자체로 장애이다.
이건 생각보다 심각한 장애인데, 사람은 이걸 장애라고 인식할 수 있지만 기계 입장에서는 그저 응답이 늦게 오는 것일 뿐 장애라고 인식하지 않기 때문이다.
그래서 일찌감치 어느 정도의 시간이 지나면 타임아웃 에러로 처리해버리는 게 서비스 입장에서 네트워크 신뢰성을 확보하는 방법이 되는 것이다.
이때 여러 홉을 거치는 트래픽일 경우 각 서비스의 타임아웃을 잘 설정하는 것이 중요하다.
통상적으로는 처음 트래픽을 받는 쪽일 수록 타임아웃 기간을 길게 책정하는 것이 좋다.
A - B - C 로 흐르는 트래픽이 있다고 쳐보자.
이때 A에 타임아웃을 1초, B에 타임아웃을 2초로 걸어버리면 B가 C로부터 트래픽을 2초동안 기다리는 동안에 A는 진즉에 타임아웃으로 처리하고 에러를 반환해버린다.
이상적인 방식은 A를 2초, B를 1초로 설정하는 것이겠다.
재시도 전략
간헐적으로 에러가 발생하는 서비스가 있다면 에러를 에러라고 끝내기보다는 차라리 에러가 날 때 재시도를 날려서 정상 응답이 돌아오게 만드는 것이 네트워크의 안정성에 더욱 바람직할 것이다.
물론 이것도 트레이드 오프가 있는데,
- 재시도만 하다가 지연이 길어져버리면 말짱 꽝..
- 타임아웃을 걸어두는 게 좋다.
- 여기에서도 thundering herd 문제가 발생할 수 있다!
- A - B - C로 흐르는 트래픽이 있을 때 각각의 재시도가 두 번까지 허용된다고 쳐보자.
- A는 B에게 두 번 재시도 요청을 할 뿐이지만 B는 A로부터 들어온 두 번의 요청에 대해 다시 각각 두 번씩 C에게 요청을 재시도할 수 있다.
- 결과적으로 하나의 요청으로 시작된 것이 C에게는 4번이나 반복돼서 들어올 수도 있는 것이다..
- 과도한 요청이 발생해 안 날 에러도 발생하는 수가 있다.
그래서 재시도 전략은 매우 신중하게 설정하는 게 좋다.
책에서는 3가지 정도의 전략을 제시한다.
- 가장 앞단에 위치한 서비스만 재시도를 시킨다.
- 재시도 횟수는 일관적으로 관리할 수 있으나, 재시도의 효용이 떨어질 수 있다.
- 전체 재시도 횟수 상한을 둔다.
- 현재의 이스티오에서는 직접적으로 이걸 설정하는 방식을 노출시키지 않고 있다.
- 이를 설정하려면 EnvoyFilter, 혹은 WasmPlugin 같은 확장 리소스로 직접 로직을 구현하던가 해야 한다.
- 최소한 재시도를 한다면 인접 지역의 엔드포인트에만 한다.
- 원격지에는 재시도를 하지 않도록 설정할 수 있긴 하다.
request hedging
실습이 제대로 되지 않는 마당에 시간까지 부족해 이 부분은 추후에 더 정리되면 실습 내용을 추가하겠다.
재시도와 타임아웃을 적절히 사용하여 짬뽕해서 나온 테크닉이 바로 리퀘스트 헤징이다.[1]
A로 가는 요청이 타임아웃 리밋에 걸렸을 때, 이걸 에러로 치지 않고 재시도를 B에 해버리는 것이다.
이제 클라는 A나 B나 어느 응답이 빨리 오는지만 따지면 된다.
어느 쪽이든 먼저 들어온 사람이 승자가 되는 방식이다!
이 방식도 이스티오가 현재 공식적으로 지원하고 있지 않지만, 엔보이에는 이 기능이 있다.
그래서 이걸 설정하고 싶다면 EnvoyFilter를 만들면 된다.
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: simple-backend-retry-hedge
namespace: istioinaction
spec:
workloadSelector:
labels:
app: simple-web
configPatches:
- applyTo: VIRTUAL_HOST
match:
context: SIDECAR_OUTBOUND
routeConfiguration:
vhost:
name: "simple-backend.istioinaction.svc.cluster.local:80"
patch:
operation: MERGE
value:
hedge_policy:
hedge_on_per_try_timeout: true
서킷 브레이커
실습이 제대로 되지 않는 마당에 시간까지 부족해 이 부분은 추후에 더 정리되면 실습 내용을 추가하겠다.
서킷 브레이커는 장애가 나는 포인트로 트래픽을 보내는 것을 차단해서 전체 서비스의 장애율을 낮추는 기법이다.
가장 간단한 예시는 쿠버네티스 서비스에서 readiness probe가 failed된 파드를 엔드포인트로 두지 않는 것을 들 수 있다.
엔보이에는 서킷 브레이커 관련 설정이 존재하는데, 이스티오에서는 이를 명시적으로 명명해 사용하진 않지만 해당 기능들을 제공하긴 한다.
여기에는 크게 두 가지 방법이 있다.
- 커넥션 풀 제한
- 클라이언트의 요청이 너무 많이 쌓일 때, 활성화된 커넥션 풀이 많다는 것은 실질적으로 제대로 트래픽을 처리할 수 없다는 것을 의미할 수도 있다.
- 설령 그게 아니라고 하더라도 과도한 연결은 서버에 많은 부하를 주게 된다.
- 그래서 일찌감치 요청이 많이 쌓여있는 상태일 때 요청을 빠르게 실패시켜 버리는 것이 하나의 서킷 브레이킹 기법이 된다.
- 이렇게 실패를 시켜버리면 다른 곳으로 트래픽을 재시도시키거나 할 수 있기 때문에 서비스가 다운되기를 기약 없이 기다리는 것보다 훨씬 안정적이다.
- 이상치 탐지
- 이건 위에서 말한 readiness probe와 비슷한 방식이다.
- 따지자면 레디네스 게이트가 더 적합할 것 같은데, 아무튼 업스트림에서 돌아오는 응답을 토대로 해당 업스트림의 건강 상태를 체크하는 것이다.
- 가령 500번대 에러를 3번 연속으로 내뱉고 있는 서비스는 무언가 문제가 있는 것이 틀림 없다고 치고 unhealthy 상태로 등록시켜버리는 수가 있다.
- 그러면 잠시 해당 서비스로는 트래픽을 아예 보내지 않음으로써 서킷 브레이킹을 해낼 수 있다.
데스티네이션 룰 connectionPool
trafficPolicy:
connectionPool:
tcp:
maxConnections: 100
connectTimeout: 30ms # 커넥션 맺을 때 기다리는 시간
tcpKeepalive: # idle 상태 유지 위해 주기적으로 작은 패킷 보내기
time: 7200s
interval: 75s
maxConnectionDuration: 1h # 한 커넥션 최대 유지 시간
idleTimeout: 1h # 트래픽 오가지 않는 상태에서 유효(idle)한 상태로 유지되는 최대 시간
업스트림 클러스터 각각의 커넥션 풀을 설정하는 필드이다.
즉 해당 클러스터를 대상으로 삼는 엔보이들은 각각 이 설정을 받게 된다.
idleTimeout
필드는 사실 리스너 쪽에 설정되는 필드라 가중치 관련 세팅이 된 환경에서 개별 적용될 수 없다.
trafficPolicy:
connectionPool:
http:
http1MaxPendingRequests: 100 # 풀이 꽉 찼을 때 큐에 저장해둘 최대 요청 수
http2MaxRequests: 1000 # 풀 크기
maxRequestsPerConnection: 10 # 한 tcp 커넥션에서 처리할 요청 개수(풀의 한 원소)
maxRetries: 3
idleTimeout: 30s
h2UpgradePolicy: UPGRADE # http1에서 http2로의 업그레이드 정책
useClientProtocol: false # 클라의 프로토콜을 그대로 쓸지
maxConcurrentStreams: 256 # http2에서 최대 동시 스트림 수
http에 대해서도 설정할 수 있다.
여기에서 커넥션 풀의 개별 단위는 tcp인 것은 동일하다.
http2MaxRequests
필드가 나타내는 것이 전체 풀 크기를 나타내는 것이라고 했는데, 이름만 보면 HTTP/2로 한정짓는 것처럼 보인다.
그러나 이건 잘못 이름 지어진 것으로, 엔보이의 설정 이름을 따라했다가 수정사항은 팔로업하지 않아서 생긴 이슈이다.[2]
실습 진행
타임아웃
네트워크 복원력에서 또 중대한 전략인 타임아웃과 재시도를 해본다.
이번 장은 대체로 데스티네이션 룰에 적용하는 거였지만, 이것들은 RDS 쪽 설정이기 때문에 버츄얼서비스에 설정해야 한다.
kubectl apply -f ch6/simple-web.yaml -n istioinaction
kubectl apply -f ch6/simple-backend.yaml -n istioinaction
kubectl delete destinationrule simple-backend-dr -n istioinaction
kubectl apply -f ch6/simple-backend-delayed.yaml -n istioinaction
기존 환경을 없애고 다시 세팅한다.
이때 똑같이 버전 1은 지연 시간이 1초가 되도록 적용한다.
SIMPLEWEB="simple-web.istioinaction.io"
for in in {1..10}; do time curl -s http://$SIMPLEWEB:30000 --resolve "$SIMPLEWEB:30000:127.0.0.1"| jq .code; done
time을 이용해 걸리는 시간을 측정하고, 이걸 보기 쉽게 한 줄로 출력한다.
curl 요청 자체야 유저, 커널 네임스페이스 어디든 시간이 걸릴리가 만무하다.
하지만 결과적으로 요청이 갔다가 돌아오는 시간은 적나라하게 반영되어 출력되고 있다.
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: simple-backend-vs
spec:
hosts:
- simple-backend
http:
- route:
- destination:
host: simple-backend
timeout: 0.5s
그렇다면 이제 이 1초는 에러라고 치부해버리자.
사용자 경험에서 웹 페이지가 1초 이상 로딩 걸린다?
나라면 바로 나간다.
나같은 허겁지겁쟁이들을 고려하는 착실한 엔지니어로서, 0.5초 이상 지나는 트래픽은 에러라고 치부하자.
SIMPLEWEB="simple-web.istioinaction.io"
for in in {1..50}; do curl -s http://$SIMPLEWEB:30000 --resolve "$SIMPLEWEB:30000:127.0.0.1"| jq ".upstream_calls[0].body"; done | sort | uniq -c | sort -nr
일단 반복 접속을 해두면, 위와 같이 에러가 발생하는 것을 볼 수 있다!
에러로 돌아온 응답시간도 볼 수 있는데, 최소 0.5초가 지난 뒤에 에러로 처리되어 그 정도의 시간이 소요된다.
키알리에서 빠르게 디버깅할 때 선을 눌러서 코드를 볼 수 있다.
여기에 플래그 정보가 같이 들어있는데, 이것은 엔보이의 플래그 정보이다.[3]
로그로 뜯어보면 이거 잘 안 보여서 좀 귀찮은데, 키알리 갓이다.
아무튼 UT는 UpstreamRequestTimeout, DC는 DownstreamConnectionTerminatioin을 뜻한다.
개인적으로는 조금 재밌는 결과라고 생각하는데, 버츄얼 서비스의 설정은 전에 말했다시피 다운스트림 호스트의 라우팅 필드에 설정을 가한다.
어차피 두 에러 모두 결국 다운스트림 측에서 낸 에러이긴 할 텐데 퍼센티지가 다르게 분할됐다는 것이 무얼 뜻하는 건지 궁금하다.
k logs simple-backend-1-7f79b4688d-hwclj --container istio-proxy
백엔드의 로그를 구체적으로 뜯어보니, DC는 업스트림 측에서 내뱉는 에러로 응답을 돌려보내지 못했으니 반환값도 없다.
반대로 웹의 로그를 뜯어보면 UT가 발생하고 504 에러 처리를 해버린다.
그 이후 클라이언트에 처음 들어왔던 요청을 반환할 때는 500 에러로 반환하고 있다.
아까처럼 다시 걸리는 시간과 응답 코드를 보자.
500에러가 몇 번 나지만 최소한 해당 요청에 대해서는 0.5초의 시간이 지나고 바로 응답이 돌아온다.
여기에서 500에러로 표시되는 이유는 위에서 봤듯 백엔드로부터(라고 생각하지만 자신의 프록시인..) 504 에러를 받은 웹 서버가 500을 반환하기 때문이다.
이제 나같은 허겁지겁쟁이들이 빠르게 서비스 이용을 포기할 수 있게 됐다!
버츄얼 서비스에서 이뤄지는 설정이다보니 해당 설정은 라우터 쪽에서 볼 수 있다.
재시도
맨 처음 이스티오를 접할 때 실습해봤던 재시도를 마지막으로 다룬다.
kubectl apply -f ch6/simple-web.yaml -n istioinaction
kubectl apply -f ch6/simple-backend.yaml -n istioinaction
다시 초기 상태로 되돌리자.
어떤 엔보이를 붙잡고 들어가도 일단 재시도 정책은 이스티오 전역적으로 2회로 잡혀있다.
즉, 요청이 실패하더라도 기본적으로 최대 3번까지 요청을 날린다는 뜻이다(2번까지 재시도하고 마지막 요청까지 날리므로).
그러나 이런 방식은 위에서 봤듯이 불필요한 thundering herd 문제를 야기한다.
그래도 기본으로 재시도를 하는 에러의 종류가 정해져있는데, 이로 인해 아무 에러에 대해 전부 재시도를 하는 것은 아니긴 하다.
- connect-failure - tcp 연결 실패
- refused-stream - http2 연결 간 실패
- unavailable - gRPC 상태 코드 14
- cancelled - gRPC 상태 코드 1
- retriable-status-codes - 재시도 가능 코드(http 503이 대표적)
기본 세팅에서는 이 케이스가 아니면 재시도를 하진 않는다.
meshConfig:
defaultHttpRetryPolicy:
attempts: 0
아무튼 이스티오 오퍼레이터 양식 파일에서 재시도 정책을 0으로 설정해서 전역 재시도 정책을 비활성화하자.
kubectl apply -f ch6/simple-backend-periodic-failure-503.yaml -n istioinaction
이제 버전 1에서 간헐적으로 503에러를 내뱉도록 만들어본다.
해당 예제는 75퍼센트 확률로 에러를 내뱉도록 돼있다.
얼추 그 정도 에러를 내뱉는 것이 확인된다.
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: simple-backend-vs
spec:
hosts:
- simple-backend
http:
- route:
- destination:
host: simple-backend
retries:
attempts: 2
재시도 정책을 설정하는 것 자체는 매우 간단하다.
그러나 이스티오 1.21 현 버전에서는 재시도 정책을 제대로 명시하지 않으면 기본적으로 503에 대해 재시도를 진행하지 않는다.[4]
재시도 설정도 이전과 동일한 모습을 보인다.
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: simple-backend-vs
spec:
hosts:
- simple-backend
http:
- route:
- destination:
host: simple-backend
retries:
attempts: 2
retryOn: 5xx
어떤 요청에 재시도를 할지 명확하게 지정한다.
이렇게 적용하면 키알리 상으로는 에러율이 동일해 보인다.
그러나 이 부분은 1주차에서 봤듯이, 메트릭 수집 설정에서 서버 측 메트릭을 비활성화해야 비로소 재시도를 포함하지 않고 시각화를 할 수 있다.
귀찮아서 이 부분은 생략.
대신 웹쪽 상태를 보면 에러가 확실하게 줄어드는 것을 확인할 수 있다.
결론
재시도 전략은 양날의 검이라고 생각한다.
커넥션, 부하 등의 이유로 발생하는 간헐적인 에러 상황에 대해서 재시도를 하는 것은 의미가 있을 수 있지만, 되려 재시도는 네트워크 부하를 지나치게 유발할 수 있다.
운영하는 입장에서 서비스 메시의 전체 아키텍쳐가 명확하게 정리돼있다면 장애가 날 만한 지점에 각각 이 전략을 커스텀해서 사용하는 것도 가능하겠으나, 총체적인 관리를 용이하게 하면서 실리를 챙기려면 사용자와 맞닿게 되는 프론트 부근에 재시도 전략을 적용하는 게 차선이지 않을까 싶다.
번외 - kubectl 버그
이건 스터디를 하면서 가시다님이 언급하신 문제를 탐구하면서 정리한 글이다.
실습하면서 워크로드 환경변수 값을 변경해서 적용했는데, 막상 해당 파드의 정보를 보면 값이 제대로 반영이 안 되는 이슈가 있었다.
결론적으로 말하자면 세팅에 문제가 있었던 건 아니고, kubectl의 동작에 문제가 있었던 것으로 추정된다.
kubectl exec -it deploy/simple-backend-1 -n istioinaction -- env | grep TIMING
버전 1에 대해 지연이 걸리도록 세팅을 한 후 exec을 해서 확인을 하는 상황이다.
막상 디플로이먼트가 관리하는 파드를 뜯어보면 원래 1000ms로 나와야할 환경변수가 적용되지 않은 것처럼 보인다.
조금 더 보니, 버전 1 디플로이먼트의 하위로 잡힌 파드가 버전2인 것처럼 보인다!
keti simple-backend-1-7f79b4688d-hwclj -- env
이상하다 싶어서 아예 대놓고 버전 1의 파드를 대상으로 exec을 실행했더니, 이번에는 값이 제대로 반영된 것이 확인됐다.
이 문제가 왜 일어나는가 잠시 고민했는데, 아무래도 이건 kubectl이 오동작하는 게 아닐까 하는 생각이 들었다.
디플로이먼트 리소스를 기반으로 exec을 하려할 때, kubectl은 디플로이먼트의 라벨을 기반으로 임의의 파드를 찾아 해당 파드에 exec을 날리는 것은 아닐까?
kubectl exec -v 8 -it deploy/simple-backend-1 -n istioinaction -- printenv | grep M
원인을 파악하는 방법은 매우 쉽다.
그냥 중간 과정을 추적해본다.
이를 기반으로 디플로이먼트를 이용해서 exec 요청을 날리는 원리를 알아내면 된다.
답도 매우 심플했다.
일단 처음에 kubectl에서는 디플로이먼트에 대해 get 요청을 때려버린다.
그리고 돌아온 양식 정보를 바탕으로 라벨 셀렉터 값을 읽은 뒤에 그냥 라벨 셀렉터로 파드를 또 get 요청 때린다;;
그리고 마지막으로 나온 파드 리스트 중 하나를 골라 기본 컨테이너에 exec 요청을 날리는 것이다.
몇 번이고 반복해서 테스트를 해본 결과, exec을 하기 위해 선택되는 파드는 항상 리스트 상에서 가장 마지막 파드이다.
로그를 계속 봐도 이전 명령에서 캐싱돼서 나온 결과도 아니었다.
내 입장에서 이러한 방식은 명백한 버그이다.
이런 식의 문제가 있는 게 당연한 거였다면 애초에 kubectl에서 파드 관련 명령(logs, exec)에 대해 워크로드 리소스를 기반으로 실행할 수 있게 하면 안 됐다.
이전 글, 다음 글
다른 글 보기
이름 | index | noteType | created |
---|---|---|---|
1W - 서비스 메시와 이스티오 | 1 | published | 2025-04-10 |
1W - 간단한 장애 상황 구현 후 대응 실습 | 2 | published | 2025-04-10 |
1W - Gateway API를 활용한 설정 | 3 | published | 2025-04-10 |
1W - 네이티브 사이드카 컨테이너 이용 | 4 | published | 2025-04-10 |
2W - 엔보이 | 5 | published | 2025-04-19 |
2W - 인그레스 게이트웨이 실습 | 6 | published | 2025-04-17 |
3W - 버츄얼 서비스를 활용한 기본 트래픽 관리 | 7 | published | 2025-04-22 |
3W - 트래픽 가중치 - flagger와 argo rollout을 이용한 점진적 배포 | 8 | published | 2025-04-22 |
3W - 트래픽 미러링 패킷 캡쳐 | 9 | published | 2025-04-22 |
3W - 서비스 엔트리와 이그레스 게이트웨이 | 10 | published | 2025-04-22 |
3W - 데스티네이션 룰을 활용한 네트워크 복원력 | 11 | published | 2025-04-26 |
3W - 타임아웃, 재시도를 활용한 네트워크 복원력 | 12 | published | 2025-04-26 |
4W - 이스티오 메트릭 확인 | 13 | published | 2025-05-03 |
4W - 이스티오 메트릭 커스텀, 프로메테우스와 그라파나 | 14 | published | 2025-05-03 |
4W - 오픈텔레메트리 기반 트레이싱 예거 시각화, 키알리 시각화 | 15 | published | 2025-05-03 |
4W - 번외 - 트레이싱용 심플 메시 서버 개발 | 16 | published | 2025-05-03 |
5W - 이스티오 mTLS와 SPIFFE | 17 | published | 2025-05-11 |
5W - 이스티오 JWT 인증 | 18 | published | 2025-05-11 |
5W - 이스티오 인가 정책 설정 | 19 | published | 2025-05-11 |
6W - 이스티오 설정 트러블슈팅 | 20 | published | 2025-05-18 |
6W - 이스티오 컨트롤 플레인 성능 최적화 | 21 | published | 2025-05-18 |
8W - 가상머신 통합하기 | 22 | published | 2025-06-01 |
8W - 엔보이와 iptables 뜯어먹기 | 23 | published | 2025-06-01 |
9W - 앰비언트 모드 구조, 원리 | 24 | published | 2025-06-07 |
9W - 앰비언트 헬름 설치, 각종 리소스 실습 | 25 | published | 2025-06-07 |
7W - 이스티오 메시 스케일링 | 26 | published | 2025-06-09 |
7W - 엔보이 필터를 통한 기능 확장 | 27 | published | 2025-06-09 |
관련 문서
이름 | noteType | created |
---|---|---|
Istio VirtualService | knowledge | 2025-04-21 |
3W - 버츄얼 서비스를 활용한 기본 트래픽 관리 | published | 2025-04-22 |
3W - 트래픽 가중치 - flagger와 argo rollout을 이용한 점진적 배포 | published | 2025-04-22 |
3W - 타임아웃, 재시도를 활용한 네트워크 복원력 | published | 2025-04-26 |